<?php
// $Id: Batch.php 27 2010-11-16 12:49:54Z mjohnson60@gmail.com $

/**
 * Enter description here ...
 * 
 * @author Mark Phillip Johnson
 * @version 1.0
 * @copyright Copyright (c) 2010, markjohnson
 * @license http://www.gnu.org/licenses/gpl.html The GNU General Public License v3.0
 *
 */
class Batch extends Object 
{
	/**
	 * @var Batch Enter description here ...
	 */
	private static $instance = NULL;
	
	/**
	 * @var array Enter description here ...
	 */
	protected static $batch = array();
	
	/**
	 * __construct()
	 * 
	 * Enter description here ...
	 * 
	 */
	public function __construct() 
	{
		self::$instance =& $this;
		
		parent::__construct();
		
		self::$batch = array('sets' => array());
	}

	/**
	 * getInstance()
	 * 
	 * Enter description here ...
	 * 
	 * @return Ambiguous
	 */
	public static function &getInstance()
	{
		if (!isset(self::$instance))
		{
			new Config();
		}
		
		return self::$instance;
	}
	
	/**
	 * Opens a new batch.
	 *
	 * @param $batch
	 *   An array defining the batch. The following keys can be used -- only
	 *   'operations' is required, and batch_init() provides default values for
	 *   the messages.
	 *   - 'operations': Array of function calls to be performed.
	 *     Example:
	 *     @code
	 *     array(
	 *       array('my_function_1', array($arg1)),
	 *       array('my_function_2', array($arg2_1, $arg2_2)),
	 *     )
	 *     @endcode
	 *   - 'title': Title for the progress page. Only safe strings should be passed.
	 *     Defaults to t('Processing').
	 *   - 'init_message': Message displayed while the processing is initialized.
	 *     Defaults to t('Initializing.').
	 *   - 'progress_message': Message displayed while processing the batch.
	 *     Available placeholders are @current, @remaining, @total, @percentage,
	 *     @estimate and @elapsed. Defaults to t('Completed @current of @total.').
	 *   - 'error_message': Message displayed if an error occurred while processing
	 *     the batch. Defaults to t('An error has occurred.').
	 *   - 'finished': Name of a function to be executed after the batch has
	 *     completed. This should be used to perform any result massaging that
	 *     may be needed, and possibly save data in $_SESSION for display after
	 *     final page redirection.
	 *   - 'file': Path to the file containing the definitions of the
	 *     'operations' and 'finished' functions, for instance if they don't
	 *     reside in the main .module file. The path should be relative to
	 *     base_path(), and thus should be built using drupal_get_path().
	 *
	 * Operations are added as new batch sets. Batch sets are used to ensure
	 * clean code independence, ensuring that several batches submitted by
	 * different parts of the code (core / contrib modules) can be processed
	 * correctly while not interfering or having to cope with each other. Each
	 * batch set gets to specify his own UI messages, operates on its own set
	 * of operations and results, and triggers its own 'finished' callback.
	 * Batch sets are processed sequentially, with the progress bar starting
	 * fresh for every new set.
	 */
	public static function set($definition) 
	{
		if ($definition) 
		{
			$batch =& Batch::get();
			
			$init = array(
				'sandbox' => array(),
				'results' => array(),
				'success' => FALSE
			);
			
			$defaults = array(
				'title' => 'Processing',
				'init_message' => 'Initializing.',
				'progress_message' => 'Remaining @remaining of @total',
				'error_message' => 'An error has occurred'
			);
			
			$batch_set = $init + $definition + $defaults;
			
			// Tweak init_message to avoid the bottom of the page flickering down after init phase.
			$batch_set['init_message'] .= '<br/>&nbsp;';
			$batch_set['total'] = count($batch_set['operations']);
			
			// If the batch is being processed (meaning we are executing a stored submit handler),
			// insert the new set after the current one.
			if (isset($batch['current_set']))
			{
				// array_insert does not exist...
				$slice1 = array_slice($batch['sets'], 0, $batch['current_set'] + 1);
				$slice2 = array_slice($batch['sets'], $batch['current_set'] + 1);
				$batch['sets'] = array_merge($slice1, array($batch_set), $slice2);
			}
			else
			{
				$batch['sets'][] = $batch_set;
			}
		}
	}
	
	/**
	 * process()
	 * 
	 * Enter description here ...
	 * 
	 * @param unknown_type $redirect
	 * @param unknown_type $url
	 */
	public static function process($redirect = NULL, $url = NULL) 
	{
		$batch =& Batch::get();
		
		if (!empty($batch)) 
		{
			$url = isset($url) ? $url : 'batch';
			$process_info = array(
				'current_set' => 0,
				'progressive' => TRUE,
				'url' => $url,
				'source_page' => $_GET['q'],
				'redirect' => $redirect
			);
			
			$batch += $process_info;
			
			if ($batch['progressive']) 
			{
				if (isset($_REQUEST['destination'])) 
				{
					$batch['destination'] = $_REQUEST['destination'];
					unset($_REQUEST['destination']);
				}
				else if (isset($_REQUEST['edit']['destination'])) 
				{
					$batch['destination'] = $_REQUEST['edit']['destination'];
					unset($_REQUEST['edit']['destination']);
				}
				
				Db::query("INSERT INTO batch (token, timestamp) VALUES ('', %d)", time());
				$batch['id'] = Db::lastInsertId('batch', 'bid');
				
				$batch['error_message'] = 'Please continue to <a href="' . $url . '?id=' . $batch['id'] . '&op=finished">the error page</a>';
				
				Db::query("UPDATE batch SET token = '%s', batch = '%s' WHERE bid = %d", get_token($batch['id']), serialize($batch), $batch['id']);
				
				Athena::redirect($batch['url'], array('query' => 'op=start&id=' . $batch['id']));
			}
			else 
			{
				Batch::_process();
//				die('Batch excution is non-progressive: ' . __FILE__ . ' [' . __FUNCTION__ . ': ' . __LINE__ . ']');
			}
		}
	}
	
	/**
	 * _process()
	 * 
	 * Enter description here ...
	 * 
	 * @return multitype:number string 
	 */
	public static function _process() 
	{
		$batch =& Batch::get();
		$current_set =& Batch::currentSet();
		$set_changed = TRUE;
		
		if ($batch['progressive'])
		{
			timer_start('batch_processing');
		}
		
		while (!$current_set['success'])
		{
			// If this is the first time we iterate this batch set in the current
			// request, we check if it requires an additional file for functions
			// definitions.
			if ($set_changed && isset($current_set['file']) && is_file($current_set['file']))
			{
				include_once ($current_set['file']);
			}
			
			$finished = 1;
			$task_message = '';
			if ((list($function, $args) = reset($current_set['operations'])) && function_exists($function))
			{
				// Build the 'context' array, execute the function call,
				// and retrieve the user message.
				$batch_context = array(
					'sandbox' => &$current_set['sandbox'], 
					'results' => &$current_set['results'], 
					'finished' => &$finished, 
					'message' => &$task_message
				);
				
				// Process the current operation.
				call_user_func_array($function, array_merge($args, array(&$batch_context)));
			}
			
			if ($finished == 1)
			{
				// Make sure this step isn't counted double when computing $current.
				$finished = 0;
				// Remove the operation and clear the sandbox.
				array_shift($current_set['operations']);
				$current_set['sandbox'] = array();
			}
			
			// If the batch set is completed, browse through the remaining sets,
			// executing 'control sets' (stored form submit handlers) along the way -
			// this might in turn insert new batch sets.
			// Stop when we find a set that actually has operations.
			$set_changed = FALSE;
			$old_set = $current_set;
			while (empty($current_set['operations']) && ($current_set['success'] = TRUE) && Batch::nextSet())
			{
				$current_set = & Batch::currentSet();
				$set_changed = TRUE;
			}
			// At this point, either $current_set is a 'real' batch set (has operations),
			// or all sets have been completed.
			

			// If we're in progressive mode, stop after 1 second.
			if ($batch['progressive'] && timer_read('batch_processing') > 1000)
			{
				break;
			}
		}
		
		if ($batch['progressive'])
		{
			// Gather progress information.
			
			// Reporting 100% progress will cause the whole batch to be considered
			// processed. If processing was paused right after moving to a new set,
			// we have to use the info from the new (unprocessed) one.
			if ($set_changed && isset($current_set['operations']))
			{
				// Processing will continue with a fresh batch set.
				$remaining = count($current_set['operations']);
				$total = $current_set['total'];
				$progress_message = $current_set['init_message'];
				$task_message = '';
			}
			else
			{
				$remaining = count($old_set['operations']);
				$total = $old_set['total'];
				$progress_message = $old_set['progress_message'];
			}
			
			$current = $total - $remaining + $finished;
			$percentage = $total ? floor($current / $total * 100) : 100;
			$values = array(
				'@remaining' => $remaining, 
				'@total' => $total, 
				'@current' => floor($current), 
				'@percentage' => $percentage
			);
			
			$message = strtr($progress_message, $values) . '<br/>';
			$message .= $task_message ? $task_message : '&nbsp';
			
			return array($percentage, $message);
		}
		else
		{
			// If we're not in progressive mode, the whole batch has been processed by now.
			return Batch::_finished();
		}
	}
	
	/**
	 * get()
	 * 
	 * Enter description here ...
	 * 
	 * @return multitype:
	 */
	public static function get() 
	{
		return self::$batch;
	}
	
	/**
	 * currentSet()
	 * 
	 * Enter description here ...
	 * 
	 */
	public static function currentSet() 
	{
		$batch =& Batch::get();
		return $batch['sets'][$batch['current_set']];
	}
	
	/**
	 * nextSet()
	 * 
	 * Enter description here ...
	 * 
	 * @return boolean
	 */
	public static function nextSet() 
	{
		$batch =& Batch::get();
		if (isset($batch['sets'][$batch['current_set'] + 1]))
		{
			$batch['current_set']++;
			$current_set =& Batch::currentSet();
			
			if (isset($current_set['form_submit']) && ($function = $current_set['form_submit']) && function_exists($function))
			{
				// We use our stored copies of $form and $form_state, to account for
				// possible alteration by the submit handlers.
				$function($batch['form'], $batch['form_state']);
			}
			
			return TRUE;
		}
	}
	
	/**
	 * page()
	 * 
	 * Enter description here ...
	 * 
	 * @return string
	 */
	public static function page() 
	{
		$batch =& Batch::get();
		
		if (isset($_REQUEST['id']) && ($data = Db::result(Db::query("SELECT batch FROM batch WHERE bid = %d AND token = '%s'", $_REQUEST['id'], get_token($_REQUEST['id']))))) 
		{
			$batch = unserialize($data);
		}
		else 
		{
			return FALSE;
		}
		
		register_shutdown_function(array('Batch', 'shutdown'));
		
		$op = isset($_REQUEST['op']) ? $_REQUEST['op'] : '';
		$output = NULL;
		switch ($op) 
		{
			case 'start':
				$output = Batch::_start();
				break;
			case 'do':
				$output = Batch::_do();
				break;
			case 'do_nojs':
				$output = Batch::progressPageNoJs();
				break;
			case 'finished':
				$output = Batch::_finished();
				break;
		}
		
		return $output;
	}
	
	/**
	 * _start()
	 * 
	 * Enter description here ...
	 * 
	 * @return string|Ambigous <string, Ambiguous, mixed>
	 */
	public static function _start() 
	{
		if (isset($_COOKIE['has_js']) && $_COOKIE['has_js']) 
		{
			return Batch::progressPageJs();
		}
		else 
		{
			return Batch::progressPageNoJs();
		}
	}
	
	/**
	 * _do()
	 * 
	 * Enter description here ...
	 * 
	 * @return string
	 */
	public static function _do() 
	{
		// HTTP POST required
		if ($_SERVER['REQUEST_METHOD'] != 'POST')
		{
			Athena::setMessage('HTTP POST is required.', 'error');
			page_title('Error');
			return '';
		}
		
		// Perform actual processing.
		list($percentage, $message) = Batch::_process();
		
		Athena::json(array('status' => TRUE, 'percentage' => $percentage, 'message' => $message));
	}
	
	/**
	 * End the batch processing:
	 * Call the 'finished' callbacks to allow custom handling of results,
	 * and resolve page redirection.
	 */
	public static function _finished() 
	{
		$batch =& Batch::get();
		
		// Execute the 'finished' callbacks for each batch set.
		foreach ($batch['sets'] as $key => $batch_set)
		{
			if (isset($batch_set['finished']))
			{
				// Check if the set requires an additional file for functions definitions.
				if (isset($batch_set['file']) && is_file($batch_set['file']))
				{
					include_once ($batch_set['file']);
				}
				if (function_exists($batch_set['finished']))
				{
					$batch_set['finished']($batch_set['success'], $batch_set['results'], $batch_set['operations']);
				}
			}
		}
		
		// Cleanup the batch table and unset the global $batch variable.
		if ($batch['progressive'])
		{
			Db::query("DELETE FROM batch WHERE bid = %d", $batch['id']);
		}
		
		$_batch = $batch;
		$batch = NULL;
		
		// Redirect if needed.
		if ($_batch['progressive'])
		{
			// Put back the 'destination' that was saved in batch_process().
			if (isset($_batch['destination']))
			{
				$_REQUEST['destination'] = $_batch['destination'];
			}
			
			// Use $_batch['form_state']['redirect'], or $_batch['redirect'],
			// or $_batch['source_page'].
			if (isset($_batch['form_state']['redirect']))
			{
				$redirect = $_batch['form_state']['redirect'];
			}
			elseif (isset($_batch['redirect']))
			{
				$redirect = $_batch['redirect'];
			}
			else
			{
				$redirect = $_batch['source_page'];
			}
			
			// Let drupal_redirect_form handle redirection logic.
			$form = isset($batch['form']) ? $batch['form'] : array();
			if (empty($_batch['form_state']['rebuild']) && empty($_batch['form_state']['storage']))
			{
				Form::redirect($form, $redirect);
			}
			
			// We get here if $form['#redirect'] was FALSE, or if the form is a
			// multi-step form. We save the final $form_state value to be retrieved
			// by drupal_get_form, and we redirect to the originating page.
			$_SESSION['batch_form_state'] = $_batch['form_state'];
			
			Athena::redirect($_batch['source_page']);
		}
	}
	
	/**
	 * progressPageJs()
	 * 
	 * Enter description here ...
	 * 
	 * @return string
	 */
	public static function progressPageJs() 
	{
		$batch = Batch::get();
		
		$current_set = Batch::currentSet();
		page_title($current_set['title']);
		Athena::addScripts('misc/progress.js', 'core', 'header', FALSE);
		
		$url = url($batch['url'], array('query' => array('id' => $batch['id'])));
		
		$js_setting = array(
			'batch' => array(
				'errorMessage' => $current_set['error_message'] . '<br />' . $batch['error_message'],
				'initMessage' => $current_set['init_message'],
				'uri' => $url
			)
		);
		
		Athena::addScripts($js_setting, 'setting');
		
		$output = '<div id="progress"></div>';
		return $output;
	}
	
	/**
	 * progressPageNoJs()
	 * 
	 * Enter description here ...
	 * 
	 * @return string
	 */
	public static function progressPageNoJs() 
	{
		$batch =& Batch::get();
		$current_set = Batch::currentSet();
		
		page_title($current_set['title']);
		
		$new_op = 'do_nojs';
		
		if (!isset($batch['running'])) 
		{
			$percentage = 0;
			$message = $current_set['init_message'];
			$batch['running'] = TRUE;
		}
		else 
		{
			ob_start();
			
			$fallback = $current_set['error_message'] . '<br />' . $batch['error_message'];
			maintenance_theme();
			$fallback = render('maintenance_page', $fallback, FALSE, FALSE);
			
			list($fallback) = explode('<!--partial-->', $fallback);
			print($fallback);
			
			list($percentage, $message) = Batch::_process();
			if ($percentage == 100) 
			{
				$new_op = 'finished';
			}
			
			ob_end_clean();
		}
		
		$url = url($batch['url'], array('query' => array('id' => $batch['id'], 'op' => $new_op)));
		Athena::setHtmlHead('<meta http-equiv="Refresh" content="0; URL=' . $url . '">');
		$output = render('progress_bar', $percentage, $message);
		
		return $output;
	}
}
